今天開始要來建立後端伺服器,本次開發框架選用 gin+postgreSQL 來架設,首先我們先安裝 gin。
範例程式碼:https://github.com/ksw2000/ironman-2024/tree/master/golang-server/whisper
go get -u github.com/gin-gonic/gin
由於我們的伺服器可能與前端的裝置不在同一個網域,因此我們需要在 router
中加上一層 middleware,這部分參考 https://stackoverflow.com/questions/29418478/go-gin-framework-cors
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
接著我們依照 Day 25 所設計的 API 開始建構。我們先此「註冊」新用戶的邏輯開始下手:
func main() {
router := gin.Default()
// gin.SetMode(gin.ReleaseMode)
router.Use(CORSMiddleware())
router.POST("/api/v1/users", func(c *gin.Context) {
// ...
})
router.Run(":8081")
}
根據我們的設計,需要取得相關的 JSON 輸入,我們將這些輸入包裝成 UserRequest
並將此檔案封裝在 users
package 中,後面的 tag 中 binding:"required"
代表這些在 POST
時都必需給值。
package users
type UserRequest struct {
Name string `json:"name" binding:"required"`
UserID string `json:"user" binding:"required"`
Password string `json:"password" binding:"required"`
Email string `json:"email" binding:"required"`
Pin string `json:"pin" binding:"required"`
PublicKey string `json:"public_key" binding:"required"`
EncryptedPrivateKey string `json:"encrypted_private_key" binding:"required"`
}
.
└── whipser/
├── users/
│ └── users.go
├── go.mod
├── go.sum
└── main.go
回到 main.go
我們開始解悉 req
,如果有值沒有給到,就回傳 bad request
router.POST("/api/v1/users", func(c *gin.Context) {
req := users.UserRequest{}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
// TODO 處理 INPUT
c.JSON(http.StatusOK, gin.H{"error": ""})
})
這裡我們直接示範一次,試試看完全沒有 input
的情況
> curl -X POST http://localhost:8081/api/v1/users -H "Content-Type: application/json" -d "{}"
{"error":"bad request"}
接著試試看完整的 input
> curl -X POST http://localhost:8081/api/v1/users -H "Content-Type: application/json" -d "{\"name\":\"日野下花帆\", \"user\": \"kaho_hinoshita\", \"password\": \"NozomiNirei0421\", \"email\": \"kahou@example.com\", \"pin\": \"20010421\", \"public_key\": \"test\", \"encrypted_private_key\": \"test\"}"
{"error":""}
為了使用資料庫的功能,我們選用 pg 這個套件
go get -u go get github.com/go-pg/pg/v10
首先,先連結資料庫:
db := pg.Connect(&pg.Options{
Addr: "localhost:5432",
User: "postgres",
Password: "example",
Database: "whisper",
})
defer db.Close()
以上組態設定的部分,日後我們可以再額外抽出來以主要程式碼之外的 json
做設定。當我們處理 API 時,僅需將 db
與取得的資料送給函式做處理並取得相關回傳值再返回給 Client 即可。
註冊新用戶整個邏輯有點複雜,首先我們要在後端檢查各種格式:比如 email、帳號、密碼、PIN 碼。接著我們要產生鹽值,並且對密碼和 PIN 碼做 SHA-256 雜湊存於資料庫。
首先,我們先檢查各種格式,檢查 Email:
func checkEmail(email string) error {
r := regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
if !r.MatchString(email) {
return ErrorInvalidEmail
}
return nil
}
檢查帳號,我們這邊假設 ID 僅包含英文字母、下劃線及點 5~30 字元:
func checkUserID(userID string) error {
r := regexp.MustCompile(`^[a-zA-Z0-9_.]{5,30}$`)
if !r.MatchString(userID) {
return ErrorInvalidUserID
}
return nil
}
檢查密碼,我們這邊假設密碼要包含英文大小寫及數字且介於 12 ~ 50 字元:
func checkPassword(password string) error {
if len(password) < 12 && len(password) > 50 {
return ErrorInvalidPassword
}
var hasUpper, hasLower, hasNumber bool
for _, char := range password {
switch {
case unicode.IsUpper(char):
hasUpper = true
case unicode.IsLower(char):
hasLower = true
case unicode.IsNumber(char):
hasNumber = true
}
}
if !(hasUpper && hasLower && hasNumber) {
return ErrorInvalidPassword
}
return nil
}
檢查 pin 碼,我們這邊假設 pin 碼為 6 ~ 20 位數字:
func checkPin(pin string) error {
r := regexp.MustCompile(`^[0-9]{6,20}$`)
if !r.MatchString(pin) {
return ErrorInvalidPin
}
return nil
}
對應的 Error:
var (
ErrorInvalidEmail = errors.New("invalid email format")
ErrorInvalidUserID = errors.New("invalid userID format")
ErrorInvalidPassword = errors.New("invalid password format")
ErrorInvalidPin = errors.New("invalid pin format")
ErrorRepeatedUserID = errors.New("id is already in use")
ErrorRepeatedEmail = errors.New("email is already in use")
)
除此之外,我們還要產生 salt,這個部分我們可以額外建立一個 util
package
.
└── whipser/
├── users/
│ └── users.go
├── utils/
│ └── utils.go
├── go.mod
├── go.sum
└── main.go
func GenerateSalt(size int) []byte {
salt := make([]byte, size)
_, err := io.ReadFull(rand.Reader, salt)
if err != nil {
panic(err)
}
return salt
}
func HashPasswordWithSalt(password, salt []byte) [32]byte {
password = append(password, salt...)
return sha256.Sum256(password)
}
並在 users
中利用 salt 將密碼與 pin
碼都做雜湊
salt := utils.GenerateSalt(32)
hashPassword := utils.HashPasswordWithSalt([]byte(u.Password), salt)
hashPin := utils.HashPasswordWithSalt([]byte(u.Pin), salt)
接著我們要檢查使用者帳號或 Email 是否有重複,為了避免檢查時有其他用戶同時新增,我們需要使用 Transaction
當沒有衝突發生時我們使用 tx.Commit()
,最後不管成功與否都直接呼叫 tx.Rollback()
即可,因為如果已經成功 commit
了,rollback
也不會退回前一步。
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("db.Begin failed: %w", err)
}
defer tx.Rollback()
if num, err := tx.Model((*User)(nil)).Where("user_id = ?", u.UserID).Count(); err != nil {
log.Print(err)
return fmt.Errorf("tx.Model.Where.Count(user_id) failed: %w", err)
} else if num > 0 {
return ErrorRepeatedUserID
}
if num, err := tx.Model((*User)(nil)).Where("email = ?", u.Email).Count(); err != nil {
log.Print(err)
return fmt.Errorf("tx.Model.Where.Count(email) failed: %w", err)
} else if num > 0 {
return ErrorRepeatedEmail
}
v := User{
Name: u.Name,
UserID: u.UserID,
Email: u.Email,
PublicKey: u.PublicKey,
EncryptedPrivateKey: u.EncryptedPrivateKey,
HashPassword: base64.StdEncoding.EncodeToString(hashPassword[:]),
HashPin: base64.StdEncoding.EncodeToString(hashPin[:]),
Salt: base64.StdEncoding.EncodeToString(salt),
}
_, err = tx.Model(&v).Insert()
if err != nil {
return fmt.Errorf("tx.Model.Insert failed: %w", err)
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("tx.Commit failed: %w", err)
}
return nil
users
table 的 schema 如下:
create table users (
id serial not null primary key,
name text not null,
user_id text not null,
email text not null,
profile text,
public_key text not null,
encrypted_private_key text not null,
hash_password char(44) not null,
hash_pin char(44) not null,
salt char(44) not null,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
最後,我們將整個 Api 完成
router.POST("/api/v1/users", func(c *gin.Context) {
req := users.UserRequest{}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
if err := req.Register(db); err != nil {
switch err {
case users.ErrorRepeatedUserID:
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": "ID 已被使用",
})
case users.ErrorRepeatedEmail:
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": "Email 已被使用",
})
case users.ErrorInvalidEmail:
c.JSON(http.StatusBadRequest, gin.H{
"error": "Email 格式不正確",
})
case users.ErrorInvalidUserID:
c.JSON(http.StatusBadRequest, gin.H{
"error": "ID 僅包含英文字母、下劃線及點 5~30 字元",
})
case users.ErrorInvalidPassword:
c.JSON(http.StatusBadRequest, gin.H{
"error": "密碼需包含大小寫英文字母及數字 12~50 字元",
})
case users.ErrorInvalidPin:
c.JSON(http.StatusBadRequest, gin.H{
"error": "Pin 碼為 6~20 位數字",
})
default:
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "內部伺服器錯誤",
})
}
return
}
c.JSON(http.StatusOK, gin.H{"error": ""})
})
接著我們在伺服器建立一個使用者,這裡以花帆的學姐乙宗梢來示範
curl -X POST http://localhost:8081/api/v1/users -H "Content-Type: application/json" -d "{\"name\": \"乙宗梢\", \"user\": \"kozue\", \"password\": \"Kozukozu0615\", \"email\": \"kozu@example
.com\", \"pin\": \"1020615\", \"public_key\": \"example_pulic_key\", \"encrypted_private_key\": \"example_encrypted_private_key\"}"
那一串 JSON:
{
"name": "乙宗梢",
"user": "kozue",
"password": "Kozukozu0615",
"email": "kozu@example.com",
"pin": "1020615",
"public_key": "example_pulic_key",
"encrypted_private_key": "example_encrypted_private_key"
}
Path: /api/v1/auth/login
Method: POST
Request:
- user string "使用者名稱"
- password string "密碼"
Response:
success:
- 200 OK
fail:
- 401 Unauthorized 驗證失敗
- 500 Internal Server Error 伺服器端發生錯誤
content:
- token string "權杖"
- error string
首先我們先建立一個 auth
package
.
└── whipser/
├── auth/
│ └── auth.go
├── users/
│ └── users.go
├── utils/
│ └── utils.go
├── go.mod
├── go.sum
└── main.go
package auth
import "github.com/go-pg/pg/v10"
type LoginRequest struct {
UserID string `json:"user" binding:"required"`
Password string `json:"password" binding:"required"`
}
func (l *LoginRequest) Login(db *pg.DB) (token string, err error) {
// TODO
return "", nil
}
接著在 router
中加入該 API
router.POST("/api/v1/auth/login", func(c *gin.Context) {
req := auth.LoginRequest{}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
token, err := req.Login(db)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"token": "",
"error": "驗證失敗",
})
return
}
c.JSON(http.StatusUnauthorized, gin.H{
"token": token,
"error": "",
})
})
驗證使用者的方式要先向 users
表查尋是否有該 user
func (l *LoginRequest) Login(db *pg.DB) (token string, err error) {
tx, err := db.Begin()
if err != nil {
return token, fmt.Errorf("db.Begin failed: %w", err)
}
defer tx.Rollback()
res := users.User{}
err = tx.Model(&res).Where("user_id = ?", l.UserID).Select()
if err != nil {
if err == pg.ErrNoRows {
return token, ErrorAuthenticationFailed
}
return token, fmt.Errorf("tx.Model.Where.QueryOne failed: %w", err)
}
// 取得鹽值
salt, err := base64.StdEncoding.DecodeString(res.Salt)
if err != nil {
return token, fmt.Errorf("base64.StdEncoding.DecodingString(res.Salt) failed: %w", err)
}
hashPassword := utils.HashPasswordWithSalt([]byte(l.Password), salt)
// 取得資料庫中已雜湊的密碼
hashPasswordInDB, err := base64.StdEncoding.DecodeString(res.HashPassword)
if err != nil {
return token, fmt.Errorf("base64.StdEncoding.DecodingString(res.HashPassword) failed: %w", err)
}
if string(hashPassword[:]) != string(hashPasswordInDB) {
return token, ErrorAuthenticationFailed
}
// TODO
return token, tx.Commit()
}
如果查不到帳號或密碼不對都要返回錯誤。
嘗試使用錯的帳號
{
"user": "kozuee",
"password": "Kozukozu0615"
}
嘗試使用錯的密碼
{
"user": "kozuee",
"password": "Kozukozu061"
}
此時都應該回傳 401
Unauthorized。
接著我們要在資料中生成另一個表格來分發權杖給登入的用戶。另外也可以擴展欄位在此記錄裝置或 ip,如此使用者可在不同裝置間登出。
create table auth (
id serial not null primary key,
uid integer references users (id) on delete cascade,
token char(64) unique,
created_at timestamptz defualt now(),
expired_at timestamptz not null
);
對應的 struct
,注意,在 pg
這個套件中會自動將 Auth
轉換成 auths
所以我們可以額外指定一個空的 tableName
field 來表示提示將其轉為 auth
。或者我們可以將原本的 table name 更換為 auths
type Auth struct {
ID int
UID int
Token string
CreatedAt time.Time
ExpiredAt time.Time
tableName struct{} `pg:"auth"`
}
更換表格名稱的 SQL 語法
ALTER TABLE auth TO auths;
Github 中的範例程式碼為統一風格,會將 sql 中的 auth table 更換為 auths,並且 struct name 維持單數
最後我們完成 Login()
剩餘的部分
func (l *LoginRequest) Login(db *pg.DB) (token string, err error) {
// 檢查帳號密碼
// ...
// 產生長度為 64 字元的 token
token = base64.StdEncoding.EncodeToString(utils.GenerateSalt(48))
auth := Auth{
UID: res.ID,
Token: token,
ExpiredAt: time.Now().Add(time.Hour * 24 * 7),
}
if _, err = tx.Model(&auth).Insert(); err != nil {
return "", fmt.Errorf("tx.Model.Insert failed: %w", err)
}
if err = tx.Commit(); err != nil {
return "", fmt.Errorf("tx.Commit failed: %w", err)
}
return token, nil
}
嘗試登入:
> curl -X POST http://localhost:8081/api/v1/auth/login -H "Content-Type: application/json" -d "{\"user\": \"kozue\", \"password\": \"Kozukozu0615\"}"
{"error":"","token":"yB79qScvQqkj54f98lboVdJTZcnEZ6aGeXUWzAwku4AUlaFKt4N6YDCnh52OPtzX"}
當前端得到 Token 後可以將 Token 存入 SharedPreferences ,後續只要憑藉該 Token 就能進行身份驗證。
Path: /api/v1/auth/logout
Method: POST
Header:
- Authorization
Response:
success:
- 204 No Content
fail:
- 500 Internal Server Error 伺服器端發生錯誤
content:
- error string
雖然對於一個聊天 APP 來說不太會需要登出,不過我們還是來實作一下吧!登出的邏輯,刪除 auth
表格中對應的 token
即使 token
不存在,我們也不需要在意,一律都回傳 204
表示登出成功。
router.POST("/api/v1/auth/logout", func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
c.JSON(http.StatusNoContent, nil)
return
}
err := auth.Logout(db, token)
if err != nil {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "內部伺服器錯誤",
})
return
}
c.JSON(http.StatusNoContent, nil)
})
func Logout(db *pg.DB, token string) (err error) {
_, err = db.Model((*Auth)(nil)).Where("token = ?", token).Delete()
return err
}
一開始先看資料庫是否有該筆資料:
接著我們嘗試登出
> curl -X POST http://localhost:8081/api/v1/auth/logout -H "Authorization: yB79qScvQqkj54f98lboVdJTZcnEZ6aGeXUWzAwku4AUlaFKt4N6YDCnh52OPtzX"
登出成功後,再查看一次資料庫可以發現已經沒有資料了。